nestjs exception filter 설계하여 Known exception들을 500 에러가 아닌 statusCode로 응답 보내기

다음과 같은 PrismaClientKnownRequestError 에러가 발생했을 때 500에러가 아니라 AlreadyExists 같은 4XX 시리즈 에러로 보내게끔 하기 위하여 매번 Exception Class를 만드는 것은 너무 번거롭다. 따라서, NestJS Exception Filter를 만들어 적어도 Prisma가 일으키는 예외들을 처리할 수 있도록 하자.

{
    "body": {
        "title": "난이도",
        "iconSvg": "<svg width=\"100\" height=\"100\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"50\" cy=\"50\" r=\"40\" fill=\"red\" /></svg>",
        "isRequired": true,
        "highlight": true
    },
    "query": {},
    "url": "/coach-form",
    "ip": "::1",
    "exception": {
        "name": "PrismaClientKnownRequestError",
        "code": "P2002",
        "clientVersion": "5.0.0",
        "meta": {
            "target": "coachdetailkeymodels_title_key"
        }
    },
    "message": "\nInvalid `.create()` invocation in\n/Users/choiwheatley/workspace/racketime-api/src/coach/coach-form.repository.ts:29:8\n\n  26 ).then((v) => v.order);\n  27 \n  28 return await this.prisma.coachDetailKey\n→ 29   .create(\nUnique constraint failed on the constraint: `coachdetailkeymodels_title_key`",
    "error": "에러가 발생했습니다."
}

ChatGPT o4-mini-high 응답

NestJS 전역 필터 하나로 Prisma 에러를 4XX 응답으로 매핑하는 예시입니다.

// prisma-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  ConflictException,
  BadRequestException,
  NotFoundException,
  InternalServerErrorException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';

@Catch(
  Prisma.PrismaClientKnownRequestError,
  Prisma.PrismaClientValidationError,
  Prisma.PrismaClientUnknownRequestError,
)
export class PrismaExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx    = host.switchToHttp();
    const response = ctx.getResponse();
    const request  = ctx.getRequest();

    let httpEx: HttpException;

    // KnownRequestError: 코드별로 매핑
    if (exception instanceof Prisma.PrismaClientKnownRequestError) {
      switch (exception.code) {
        case 'P2002': {
          // unique constraint failed
          // meta.target에 중복된 컬럼 이름이 들어있음
          const target = Array.isArray(exception.meta.target)
            ? exception.meta.target.join(', ')
            : exception.meta.target;
          httpEx = new ConflictException(`${target} 은(는) 이미 존재합니다.`);
          break;
        }
        case 'P2025':
          // record not found for update/delete
          httpEx = new NotFoundException('요청한 리소스를 찾을 수 없습니다.');
          break;
        case 'P2003':
        case 'P2004':
        case 'P2005':
          // foreign key, value too long 등
          httpEx = new BadRequestException(exception.message);
          break;
        default:
          httpEx = new InternalServerErrorException('알 수 없는 데이터베이스 오류입니다.');
      }
    }
    // ValidationError: 스키마 검증 실패
    else if (exception instanceof Prisma.PrismaClientValidationError) {
      httpEx = new BadRequestException(exception.message);
    }
    // UnknownRequestError: 커넥션 등
    else if (exception instanceof Prisma.PrismaClientUnknownRequestError) {
      httpEx = new InternalServerErrorException('데이터베이스 요청 중 오류가 발생했습니다.');
    }
    // 그 외
    else {
      httpEx = new InternalServerErrorException('서버 오류입니다.');
    }

    response
      .status(httpEx.getStatus())
      .json({
        statusCode: httpEx.getStatus(),
        timestamp: new Date().toISOString(),
        path: request.url,
        message: httpEx.message,
      });
  }
}

그리고 이 필터를 main.ts에 전역 등록하면, Prisma 에러가 발생할 때마다 500이 아닌 적절한 4XX/5XX로 자동 변환됩니다.

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule }    from './app.module';
import { PrismaExceptionFilter } from './filters/prisma-exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 전역 필터 등록
  app.useGlobalFilters(new PrismaExceptionFilter());

  await app.listen(3000);
}
bootstrap();

코드 설명

  1. @Catch(...)
    Prisma가 던지는 주요 예외 클래스들을 한 번에 잡아냅니다.

  2. PrismaClientKnownRequestError.code

    • P2002: unique constraint 위반 → 409 Conflict
    • P2025: 대상 레코드 없음 → 404 Not Found
    • 그 외 foreign key 등 → 400 Bad Request
  3. PrismaClientValidationError
    스키마/쿼리 검증 실패 → 400 Bad Request

  4. PrismaClientUnknownRequestError
    예기치 않은 내부 요청 오류 → 500 Internal Server Error

  5. 응답 포맷

    {
      "statusCode": 409,
      "timestamp": "2025-04-30T12:34:56.789Z",
      "path": "/coach-form",
      "message": "coachdetailkeymodels.title already exists."
    }
    

이제 매번 개별 Exception 클래스를 만들지 않고도 Prisma 예외를 깔끔하게 4XX 에러로 변환할 수 있습니다.

Q. I need something MORE

아래는 “더욱 강력하게” 동작하는 Prisma 예외 필터 예시입니다.

// src/filters/prisma-exception.filter.ts
import {
  Injectable,
  Logger,
  HttpException,
  HttpStatus,
  ArgumentsHost,
  Catch,
  ExceptionFilter,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';

interface PrismaErrorMapEntry {
  status: HttpStatus;
  message: (exc: Prisma.PrismaClientKnownRequestError) => string;
}

@Injectable()
@Catch(
  Prisma.PrismaClientKnownRequestError,
  Prisma.PrismaClientValidationError,
  Prisma.PrismaClientUnknownRequestError,
)
export class PrismaExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(PrismaExceptionFilter.name);

  // 기본 매핑 테이블
  private readonly defaultMap: Record<string, PrismaErrorMapEntry> = {
    P2002: {
      status: HttpStatus.CONFLICT,
      message: exc => {
        const target = Array.isArray(exc.meta.target)
          ? exc.meta.target.join(', ')
          : exc.meta.target;
        return `${target} 은(는) 이미 존재합니다.`;
      },
    },
    P2025: {
      status: HttpStatus.NOT_FOUND,
      message: () => `요청한 리소스를 찾을 수 없습니다.`,
    },
    P2003: {
      status: HttpStatus.BAD_REQUEST,
      message: exc => exc.message,
    },
    // 필요시 여기에 추가…
  };

  private readonly errorMap: Record<string, PrismaErrorMapEntry>;

  constructor(private readonly config: ConfigService) {
    // config 서비스에서 오버라이드 가능 (예: 환경변수로 맵핑 정의)
    this.errorMap = this.config.get<Record<string, PrismaErrorMapEntry>>(
      'PRISMA_ERROR_MAP',
      this.defaultMap,
    );
  }

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx      = host.switchToHttp();
    const response = ctx.getResponse();
    const request  = ctx.getRequest();

    let status: number;
    let message: string;

    // KnownRequestError
    if (exception instanceof Prisma.PrismaClientKnownRequestError) {
      const entry = this.errorMap[exception.code];
      if (entry) {
        status  = entry.status;
        message = entry.message(exception);
      } else {
        status  = HttpStatus.INTERNAL_SERVER_ERROR;
        message = '알 수 없는 데이터베이스 오류입니다.';
      }
      this.logger.warn(
        `PrismaClientKnownRequestError(code=${exception.code}): ${exception.message}`,
      );
    }
    // ValidationError
    else if (exception instanceof Prisma.PrismaClientValidationError) {
      status  = HttpStatus.BAD_REQUEST;
      message = exception.message;
      this.logger.warn(`PrismaClientValidationError: ${message}`);
    }
    // UnknownRequestError
    else if (exception instanceof Prisma.PrismaClientUnknownRequestError) {
      status  = HttpStatus.INTERNAL_SERVER_ERROR;
      message = '데이터베이스 요청 중 오류가 발생했습니다.';
      this.logger.error(`PrismaClientUnknownRequestError: ${exception}`);
    }
    // 그 외
    else {
      status  = HttpStatus.INTERNAL_SERVER_ERROR;
      message = '서버 오류입니다.';
      this.logger.error(`Non-Prisma exception passed to filter: ${exception}`);
    }

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message,
    });
  }
}
// src/app.module.ts
import { Module }              from '@nestjs/common';
import { APP_FILTER }          from '@nestjs/core';
import { PrismaExceptionFilter } from './filters/prisma-exception.filter';
import { ConfigModule }        from '@nestjs/config';

@Module({
  imports: [ConfigModule.forRoot(/* env 설정 */)],
  providers: [
    {
      provide: APP_FILTER,
      useClass: PrismaExceptionFilter,
    },
  ],
})
export class AppModule {}

주요 개선 포인트

  1. 의존성 주입
    • Logger + ConfigService 활용 → 런타임에 매핑 수정 & 통일된 로깅
  2. 매핑 테이블
    • defaultMap에 에러코드별 status·message 정의
    • 환경변수(PRISMA_ERROR_MAP)로 쉽게 오버라이드
  3. 글로벌 필터 등록
    • APP_FILTER 토큰 사용 → 모든 컨트롤러/GraphQL에 자동 적용
  4. 확장성
    • 새로운 Prisma 에러코드 추가 시 defaultMap에 한 줄만 추가

이제 다음과 같은 장점이 있습니다.

필요하다면 여기에 i18n, 추적ID(correlation-id) 주입, Sentry 연동 등을 더해도 좋습니다!